Skip to content

fix(etl): correct/flag amendment value double-counts (#305) - #307

Merged
todorkolev merged 22 commits into
midt-bg:mainfrom
cefothe:feat/305-amendment-double-count-upstream
Aug 14, 2026
Merged

fix(etl): correct/flag amendment value double-counts (#305)#307
todorkolev merged 22 commits into
midt-bg:mainfrom
cefothe:feat/305-amendment-double-count-upstream

Conversation

@cefothe

@cefothe cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #305.

Problem

When an EOP annex announces a new total contract value, ЦАИС ЕОП puts that total in the change field (contractValueDifferencevalue_delta), so the feed's currentContractValue (→ value_after) arrives as lastContractValue + newTotal — the value is doubled. base.ts stores it verbatim (no arithmetic), and a ~2× inflation sits below every existing flag (annex_suspect/#299 need ≥5×), so it flows into amount_eur and inflates every rollup, CSV, and the contract page. Verified on the real corpus: 686 annexes at ≥100% growth; proof record 145652 shows 981 240 instead of 539 240.

Fix (three tiers, all in the ETL — no schema break beyond a nullable served column)

  1. Tier 1 — flag & exclude. New value_flag = 'annex_total_suspect' for a single annex with value_after ≥ 2×value_before (ЗОП чл.116 caps a single amendment at +50%, so ≥2× is a defect). Threaded through the value fallback exactly like annex_suspect → these contracts fall back to signing_value and drop out of every EUR aggregate, on both derive paths (normalize-raw.sql + refresh-slice.sql).
  2. Tier 2 — text-confirmed correction. A heuristic (packages/ingest/src/amendment-total.ts) reads the основание text and classifies each annex by the preposition before the figure: "…на <N>" / "обща стойност … <N>" = a new total → value_after restated to N; "…с <N>" = a genuine increment → the value is correct and is not flagged; the corrected value drives current_value and the served amendment row (with a value_restated marker so the timeline shows the right number).
  3. Tier 2b — exact-2× restatement. When value_after ≈ 2×value_before, the change field just echoed the OLD value (currency re-denomination or a non-value administrative annex); restate to value_before. Text-free, currency-agnostic; the genuine-increment check runs first so a real increase is never mis-restated.

Coverage (measured over the full 2020→2026 annex corpus)

Of the 686 doubled annexes: 362 corrected (53%), 183 flagged & excluded (27%), 10 exonerated as genuine, 25 uncaught (multi-annex chains). Isolating the double-count band #299 can't reach (580): 545/580 = 94% handled. All three named proof records resolve — including 84818 (EUR annex on a BGN contract). Full breakdown and comparison to the ticket: #305 (comment)

Honest residuals (documented in docs/implementation-plans/305-amendment-value-double-count.md)

  • ~25 multi-annex doublings where the doubled step isn't the first (escape both tiers).
  • The 183 flag-only rows still show the doubled value in the amendment timeline (only the 362 corrected rows are rewritten).
  • The sub-100% blind spot (new total lower than the old value) is undetectable by this signal.
    Closing these needs the v2 direct-total parse and cross-referencing the ЦАИС ЕОП contract feed — deliberately deferred rather than over-fitting a fragile regex on financial figures.

Tests

New: amendment-total.test.ts (heuristic vs real основание text incl. false-positive controls), amendments-total-restated.test.ts + amendments-total-suspect.test.ts (real derive→normalize/refresh-slice→promote→precompute, full-vs-slice parity). @sigma/ingest 72 + @sigma/db 400 green; tsc + prettier clean. No CI/CD or infrastructure changes.

…ct (midt-bg#305)

Tier 1 of midt-bg#305: an annex that announces a new TOTAL contract value has that total put in
ЦАИС ЕОП's change field, so currentContractValue = before + newTotal — the value doubles.
A single amendment cannot legally >2x a contract (ЗОП чл.116 caps at +50%), so a driving annex
with value_after >= 2*value_before (and value_before ~ signing_value, same currency, <10x, tied to
current_value) is a data defect. New value_flag 'annex_total_suspect' threads through the value
fallback exactly like annex_suspect: these contracts fall back to signing_value and drop out of
every EUR aggregate (amount_eur, current_value_eur), in both normalize-raw.sql and refresh-slice.sql;
details.ts renders the suspect note. Diagnostic count added.

Scope: high-precision same-currency first-annex doublings — ~310 contracts on the live corpus,
catches the issue's 145652 and 189325. Deliberately NOT covered (deferred to Tier 2, which needs the
основание free text): currency-change doublings like 84818 (exactly 2x EUR annex on a BGN contract,
data-indistinguishable from a legitimate cross-currency case) and multi-annex-chain doublings where
value_before != signing. 393/393 db tests green, no regressions.
midt-bg#305 Tier 2)

Tier 2 restates the double-counted value from the основание free text instead of only flagging it.
packages/ingest/src/amendment-total.ts classifies each EOP annex by the preposition in front of the
figure — 'на <N>'/'обща стойност … <N>' = a new TOTAL (double-count → corrected value_after = N),
'с <N>' = a genuine increment (value is correct → do NOT flag), currency re-denomination ('лева в
евро' + exact 2x) → corrected value_after = value_before. Validated against real основание text
(145652, 189325, 79382, 113291, and the false-positive controls 108677, 103903, 84818).

base.ts sets raw_amendments.value_treatment / value_after_restated at ingest; derive/normalize/
promote/refresh-slice use COALESCE(value_after_restated, value_after) for current_value and served
amendments, and exclude value_treatment-set rows from the Tier-1 annex_total_suspect flag (fixes the
108677-class false positive). Migration 0006 adds value_restated/value_treatment to served amendments;
details.ts exposes a 'restated' marker. Untreated doubles (no text signal, e.g. 84818/103903) still
fall to the Tier-1 flag. @sigma/db 400 + @sigma/ingest 71 green.
…ETL runs under plain Node (midt-bg#305)

load-eop.mjs/import.mjs import @sigma/ingest source directly under plain Node, which requires explicit
.ts extensions on imports within that graph (base.ts → './amendment-total.ts'). The extensionless import
passed vitest (esbuild) but broke the real ETL with ERR_MODULE_NOT_FOUND. Enable allowImportingTsExtensions
in the ingest tsconfig so tsc (Bundler resolution) accepts the same specifier the runtime needs.
…value (midt-bg#305 Tier 2b)

When value_after ≈ 2×value_before, the source's 'difference' field merely echoed the OLD value, so the
value is unchanged and was doubled onto itself (currency re-denomination or a non-value administrative
annex). ЗОП чл.116 caps a single amendment at +50%, so an exact +100% is a definitional defect, not a
real increase — restate value_after to value_before. Text-free and currency-agnostic; the genuine-
increment check ("с <delta>") runs first, so a legitimately-announced increase is never mis-restated.

Generalises the former currency-only rule (renamed treatment 'unchanged_restated'). Real-corpus coverage
of the 686 doubled annexes rises 25%→54% (362 corrected + 10 exonerated); all three named records now
resolve, incl. 84818 (76.77M EUR annex on a BGN contract). @sigma/ingest 72 + @sigma/db 400 green.
…idt-bg#305 precision)

The bare-'на' branch of the total detector matched Bulgarian 'в размер на <N>' ("in the amount of N"),
'ресурс … в размер на N', and 'допълнителни … на обща стойност N' — which name the CHANGE/added-work
amount, not the new contract total. Restating value_after := N there understated genuine >100% increases
and rewrote wrong numbers (verified on the real corpus: ~65 of 159 total_restated were such false
positives, incl. unit-price rows). Add a wider TOTAL_VETO (в размер / ресурс / допълнителн / увеличени /
намалени) over a ~55-char window. Real feed: total_restated 159→94, 'в размер на' contamination 69→4;
all legitimate 'от X на Y' / 'възлиза на Y' totals retained (145652, 79382, 113291). Demoted rows fall to
the Tier-1 arithmetic flag (they are ≥2×), so aggregate coverage holds while correction precision jumps.
…s the doubled value in the timeline (midt-bg#305)

annex_total_suspect is contract-level, so a flagged contract's served amendment row still showed the
doubled value_after in the timeline. Add a per-amendment value_suspect column (migration 0007), set at
promote time (promote-amendments.sql + refresh-slice.sql) for an untreated annex whose value_before ≈
signing_value and value_after in [2x,10x) with matching currency — the same arithmetic gate, at row grain.
details.ts blanks valueAfterEur/deltaEur for a suspect row; contract.tsx renders '— непотвърден тотал'.
Text-corrected (value_restated=1) and genuine-increment rows are never suspect. @sigma/db 404 + ingest 73
green, tsc clean.
…hor (midt-bg#305)

The annex_total_suspect gate only fired when a doubled annex's value_before
matched the contract's signing_value, so it caught single-annex doubles but
missed a later-in-chain annex whose value_before is the prior CUMULATIVE total
(a preceding annex's value_after). Relax the anchor across all gate sites to
value_before ≈ signing OR ≈ any recorded prior annex value_after. The ≥2×
single-step gate (ЗОП чл.116 caps one amendment at +50%) remains the defect
signal, so slow legitimate multi-annex climbs — whose steps never reach 2× —
stay untouched.

Applied consistently to the contract-level flag (normalize-raw, refresh-slice,
and the has_double CTE) and the per-row value_suspect marker (promote-amendments
and refresh-slice). Adds positive/negative regression fixtures that fail against
the pre-change SQL.
…he CI gates (midt-bg#305)

The prior-annex anchor from 6709c57 over-fired on compounding chains where
every annex doubles (fixture UNP-SLOW8: signing→2x→4x→8x): a later step could
anchor its value_before to a preceding annex value_after that was ITSELF a
double. Require the anchoring prior total to have been reached legitimately
(prev.value_after < 2x prev.value_before), so only a single >=2x step sitting on
a genuinely-grown base is flagged. Applied across all gate sites.

Also green the remaining CI gates that were red on this branch:
- typecheck: hoist allowImportingTsExtensions into tsconfig.base.json so every
  consumer of the @sigma/ingest source graph (apps/etl, packages/db) accepts
  base.ts's './amendment-total.ts' import; drop the now-redundant local copy.
- lint: prettier-format the new test fixtures.
- docs integrity: index the midt-bg#305 implementation plan in docs/README.md.

@cefothe cefothe left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — parallel specialized agents + manual verification

Phase 0 security scan: CLEAN. No secrets, no new dependencies, only external URL is this repo's own issue tracker. execFileSync('sqlite3', [...]) is test-harness code using array args (no shell injection). All 5 CI checks green.

Verdict: no blocking issues. This is a careful, conservative, genuinely well-tested change — the DB tests run the real SQL scripts against a real SQLite through the actual migration chain and pipeline order, and assert full-rebuild vs. slice-refresh parity. The design fails safe: when uncertain it flags-and-excludes or restates to a lower bound, so residual error trends toward under-correction, not corruption.

Two initial agent findings that did NOT survive verification

  • "CRITICAL: absolute tolerance ABS(am.value_after - c.current_value) < 0.01" — false positive. current_value is a direct copy of COALESCE(value_after_restated, value_after) from the driving amendment (derive-amendments.sql:149-155), no arithmetic, so the tie is exact. Absolute 0.01 is actually more correct here.
  • "HIGH: prev self-join can match am itself" — false positive. Self-match needs prev.value_after < 2×prev.value_before (normalize-raw.sql:980) while the outer gate needs am.value_after >= 2×am.value_before (line 983) — mutually exclusive.

Actionable findings

1. MEDIUM — Rule 3 (exact-2× restatement) rewrites value with no textual evidence and no ЗОП-exemption check. amendment-total.ts:124-126 fires whenever value_after ≈ 2×value_before, justified by the чл.116 +50% cap — which doesn't bind outside_zop contracts or multi-lot aggregates. A genuinely-doubled legal contract whose основание lacks "с <delta>" would be silently restated. The outside_zop field already exists on the row (base.ts:335). → fixing: thread outsideZop into the classifier and skip Rule 3 when set.

2. MEDIUM — the restated marker is plumbed through the API but never rendered. details.ts:697 sets it and api-contract/src/index.ts:285 declares it, but contract.tsx never references a.restated. A silently text-corrected value gives the user no indication SIGMA rewrote it — a transparency gap. → fixing: render a marker on the corrected row.

3. LOW/MEDIUM — full-vs-slice "latest amendment" tiebreak diverges. derive-amendments.sql:155 orders published_at DESC, natural_key DESC; the refresh-slice.sql rollup orders published_at DESC, id DESC. On annexes sharing published_at the two paths can pick different driving amendments. → fixing: align on id DESC.

Minor (LOW)

  • Tautological parity assertions (amendments-total-restated.test.ts:297-298, amendments-total-suspect.test.ts:300-301): expect(full?.X).toBe(slice?.X) after both asserted against a concrete value — passes trivially on dual-undefined.
  • No DB test for a ≥2× legitimate annex arriving with value_treatment=NULL (heuristic miss) — it gets flagged (safe direction), but worth pinning.
  • normalizeBgNumber can't parse dot-thousands 1.234.567,89 — silent miss (falls to arithmetic flag), acceptable under the conservative design.
  • Migrations 0006/0007 additive & nullable/defaulted — safe for D1.

Findings #1#3 and the tautological-assertion nit are being fixed in follow-up commits on this branch.

…acts (midt-bg#305)

The rule-3 fallback restates any value_after ≈ 2×value_before to value_before with
no textual evidence, justified by ЗОП чл.116's +50% single-amendment cap. That cap
does not bind contracts procured outside ЗОП (isExceptionContract), where a genuine
+100% is legal — so a text-free restatement there could erase a real doubling. Thread
outsideZop through classifyAmendmentValue and gate rule 3 on it; the text-confirmed
rules (total / genuine-increment) are unaffected since the double-count is a feed
defect independent of ЗОП scope. Untreated exception doubles fall to the arithmetic
flag (exclude, don't rewrite).
…#305)

The incremental rollup picked the driving amendment with ORDER BY published_at DESC,
id DESC while the full-rebuild path (derive-amendments.sql) uses natural_key DESC. On
two annexes sharing published_at the two paths could select different rows and diverge
on current_value / value_flag. Match the canonical natural_key tie-break in both the
current_value and current_value_currency subqueries.
…g#305)

The 'restated' flag was plumbed through details.ts and the API contract but never
rendered, so a value СИГМА silently corrected from a doubled total showed no indicator.
Render a 'коригиран тотал' chip beside the corrected value, parallel to the existing
'непотвърден тотал' suspect marker.
…idt-bg#305)

expect(full?.X).toBe(slice?.X) after both were pinned to a concrete literal adds no
coverage and would also pass on dual-undefined. Keep the per-path literal assertions.
@DiyanaDimitrova

Copy link
Copy Markdown
Contributor

Ревю — коригиране/маркиране на двойно броене на стойността при анекси (#305)

Заключение: искам промени (request changes). PR-ът поправя реален дефект (емисията слага новата обща стойност в полето за промяна, така че value_after идва удвоено), а триетажният подход — аритметичен флаг + текстова евристика + колона за коригираната стойност — е издържан. Рискът е изцяло в коригиращата евристика, която проверих ред по ред. Сканът за сигурност е чист (execFileSync('sqlite3') е тестовият harness; без тайни/URL-и/зависимости).

Блокиращи (2 HIGH — и двете корумпират публикувана стойност)

HIGH-1 — голото „на" пренаписва случайно съвпаднала непарична стойност (packages/ingest/src/amendment-total.ts:51,120)

TOTAL_CTX приема голо „на" без паричен котвен маркер. Проследено: {before:100, after:300, delta:200, texts:['Срокът на договора се удължава на 200 дни.']}total_restated: 200 — стойността се презаписва с брой дни, защото 200 ≈ delta. Текстовете на анексите редовно носят непарични числа (дни, номера по членове, бройки); всяко, което случайно съвпадне с value_delta след голо „на", корумпира публикуваната стойност. Поправка: искайте стойностен маркер около „на" (напр. на (обща )?стойност / на … лв.), а не голо „на".

HIGH-2 — безтекстовото правило за точно 2× разполовява легитимни удвоявания в обхвата на ЗОП (amendment-total.ts:132)

approxEq(a, 2*b) && !outsideZop пренаписва без текстово потвърждение. Проследено: {before:100, after:200, delta:100, outsideZop:false, texts:['…предварително обявена клауза за опция.']}unchanged_restated: 100, което тихо разполовява реална стойност. Таванът от +50% по чл.116 ЗОП има вътрешнообхватни изключения (предварително обявени клаузи за опция/преразглеждане, ал.1 т.1), които outsideZop не моделира. Правило 1 оневинява само текстове, които буквално казват „с "; легитимно удвоен договор, формулиран иначе, се подценява. Поправка: или искайте текстово потвърждение за 2× пренаписването, или стеснете предпоставката по ЗОП до параграфите, които тя реално урежда.

Средни (потвърдени)

  • Удвоената стойност все още се показва на страницата на договора (packages/db/src/queries/details.ts:554, apps/web/app/routes/contract.tsx:224): при annex_total_suspect current_value_eur е NULL, затова currentRaw пада обратно към удвоеното нативно current_value; страницата го рендира с етикет „непотвърдена достоверност". Понижено от HIGH на Codex — това е същият път, който всеки suspect-флаг ползва (нативна стойност + етикет), т.е. не е нова регресия — но за известно удвоена стойност показването на (както при NULL) би било по-честно от етикетирано 2× число.
  • Регулярният израз за числа пропуска 1.234,56 (amendment-total.ts:44,66): формат с точка за хиляди + запетая за десетични не се парсва → реален total не се пренаписва → Tier 1 го изключва (безопасен fallback, без корупция). Пропуск в покритието (recall), не в коректността.
  • Миграциите не са идемпотентни (0006_amendment_restated.sql:12-13, 0007_amendment_value_suspect.sql:11): три пъти ALTER TABLE … ADD COLUMN. Деплой пътят е гол d1 execute с празен ledger + сондиране на схемата (deploy.yml:148-192) — сондата трябва да пази и тези нови колони, иначе повторно/частично прилагане удря грешки за дублирана колона.

Изчистено (проверено)

Няма SQL инжекция (пренаписаните стойности са валидирани числа; стейджнатият текст е escape-нат по единични кавички). Tier 2 се смята в ingest (base.ts) преди SQL, така че и пълният (normalize-raw.sql), и slice-пътят (refresh-slice.sql) го наследяват — няма дрейф; Tier-1 fallback/агрегатите са огледални и на двата пътя. value_before ≤ 0/NULL е guard-нат. Последният комит правилно замени тавтологичните full-vs-slice проверки за равенство с конкретни assertions.

Обобщение

Искам промени по двата HIGH — реални ръбове, които корумпират стойност в самата коригираща евристика (свръх-пренаписване на непарично „на"-число; разполовяване на легитимно 2×). Останалото е стабилно: флаг-и-изключи слоят, ingest-базираното (без дрейф) пренаписване и SQL fallback-ът са коректни. Стесняване на двете правила (паричен котвен маркер за „на"; текстов гейт за 2×) плюс тестове точно за тези входове биха го изчистили.

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах PR-а на 8652d69 независимо, включително възпроизвеждане на находките от прегледа на @DiyanaDimitrova срещу истинския код. Дефектът е реален и добре разпознат, а тристепенният подход - аритметичен флаг, текстова евристика, колона за коригираната стойност - е верният скелет. Пускам обаче искане за промени по три точки, едната от които не е повдигана досега.

1. Новите колони няма как да стигнат до сервираната база (ново)

details.ts чете am.value_restated, am.value_suspect безусловно при всяко отваряне на страница на договор (AMENDMENTS_SQL). Тези колони идват само от 0006/0007. Проследих как миграция стига до сервираната база и веригата се къса:

  • deploy.yml прилага изрично само 0002 и 0003 и никъде не вика d1 migrations apply;
  • ship-domain.mjs го вика, но той не се пуска от нито един workflow - grep -rn "ship-domain" .github/workflows/ не връща нищо;
  • ledger-ът на живата stage база реално стои на 0000_init, 0001_flow_pairs_bidder_index, 0002_current_value_currency, докато схемата е стигнала до 0005. Тоест миграциите след 0003 са прилагани извън ledger-а, на ръка.

Мърдж към main деплойва Worker-а веднага. Заявката ще сочи две несъществуващи колони и всяка страница на договор ще гърми, докато някой не приложи миграциите ръчно.

Иска се едно от двете: стъпка в deploy.yml по образеца на 0002 (сонда за колоната, а не migrations apply, точно както коментарът там обяснява), или заявката да толерира липсваща колона. Първото е по-чисто.

Свързано с това е и бележката за идемпотентността: 0006/0007 са три голи ALTER TABLE ... ADD COLUMN. Ако колоните бъдат приложени извън ledger-а, както е ставало досега, а после ledger-ът ги преиграе, се получава грешка за дублирана колона. Сондата решава и това.

2. Голото „на" пренаписва стойността с непарично число

Потвърждавам HIGH-1 на @DiyanaDimitrova - възпроизведено срещу classifyAmendmentValue:

{before:100, after:300, delta:200, texts:['Срокът на договора се удължава на 200 дни.']}
  → { kind: 'total_restated', correctedAfter: 200 }

Договор със стойност 300 става 200, защото 200 е брой дни. TOTAL_CTX приема голо „на" без паричен котвен маркер, а основанията редовно носят срокове, количества и номера на членове. Достатъчно е такова число да съвпадне с value_delta в рамките на 0,5%.

Предложението в прегледа е правилното: искайте стойностен маркер около „на" (на (обща )?стойност, на … лв.), а не голо „на".

3. Правилото за точно 2× пренаписва без никакво потвърждение

Потвърждавам HIGH-2, и то е по-широко, отколкото звучи. Не става дума само за законно удвояване с текст - правилото е по подразбиране за всеки ред с точно 2×:

{before:539240, after:1078480, delta:539240, outsideZop:null, texts:[null,'','   ']}
  → { kind: 'unchanged_restated', correctedAfter: 539240 }

{before:250000, after:500000, delta:250000, outsideZop:false,
 texts:['Смяна на адреса за кореспонденция на изпълнителя.']}
  → { kind: 'unchanged_restated', correctedAfter: 250000 }

Празен текст и текст без никаква връзка със стойността дават един и същ резултат: разполовяване. Това е съзнателно и дори закотвено с тестове (84818, административният анекс), но цялата му безопасност стои на предпоставката, че чл.116 ЗОП забранява +100%. Прегледът посочва вътрешнообхватни изключения - предварително обявени клаузи за опция - които outsideZop не моделира.

Не се наемам да отсъждам правото. Структурната бележка обаче стои и е от най-неприятния сорт: пренаписваме публикувана стойност без нито едно потвърждение, а грешката в тази посока подценява реален договор и е невидима за читателя. Или текстов гейт, или по-тясна ЗОП предпоставка.

Какво проверих и е наред

  • Тестовете минават: packages/ingest 75, packages/db 407. Единственото падане е предсъществуващ артефакт на средата (ship-domain.test.ts не намира глобалния wrangler) и не е свързано с PR-а.
  • Няма SQL инжекция; стейджнатият текст е escape-нат, пренаписаните стойности са валидирани числа.
  • Tier 2 се смята в ingest преди SQL, тъй че и пълният, и slice пътят го наследяват - тук няма дрейф между двата пътя, което е точното място, където такива поправки обикновено се разминават.
  • Пазачите за value_before <= 0 / NULL и за самосъгласуваност a = b + d са налице, а вето-то за „в размер на" / „ресурс" върши работа - проверих го с контролни входове.
  • Честните остатъци в плана (25 многоанексни вериги, слепотата под 100%) са коректно описани и не се крият.

Обобщение

Скелетът е добър и си струва да влезе. Трите неща преди това: стъпка за миграциите, паричен котвен маркер за „на", и потвърждение за 2× правилото. Първото е блокер за деплоя, другите две са блокери за данните.

Благодаря за работата - и за плана в docs/implementation-plans/, който направи проследяването бързо.

@nikimilenkov nikimilenkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обстоен преглед — PR #307 @ 8652d69 (поправя #305)

Благодаря за този PR — проблемът е диагностициран прецизно, консервативната посока („при съмнение — флаг, не пренаписване") е правилната, тестовете изпълняват истинските скриптове, а покритието е измерено върху реалния масив от данни. Прегледът мина по строгия протокол (пет паралелни измерения; всяка находка проследена, възпроизведена или мутирана върху чисто копие на този HEAD), стъпва върху прегледите на @DiyanaDimitrova и @todorkolev, без да повтаря находките им — тук са само независимите потвърждения с добавени доказателства и новите неща.

Предложение: връщане за промени — присъединявам се към исканията на двамата. Общо на PR-а: 1 критична (точка 1 на @todorkolev, потвърдена тук с допълнителни последици), 2-те високи на @DiyanaDimitrova (потвърдени), 2 нови високи, плюс нови средни/ниски по-долу. (Съветодателно — решението е на поддържащите.)

Изпълнени проверки: packages/ingest 75/75, packages/db 407/408 (единственият неуспех е познат env артефакт — ship-domain.test.ts извиква pnpm без пълен път); 6 мутации върху класификатора (4 убити, 2 оцелели) + 2 SQL мутации по двата пътя (двете хванати от parity тестовете); две механични репродукции през реалните SQL скриптове; производителност, измерена при мащаба на реалните данни; план-документът прочетен и сверен ред по ред с кода; CI зелен.

Потвърждения на предишните прегледи (независимо, с изпълнение — не ги повтарям като нови)

  • HIGH-1 на @DiyanaDimitrova (голото „на") — потвърдена чрез изпълнение срещу реалния класификатор (същия вход изпълни и @todorkolev, същият резултат): total_restated: 200, т.е. сервирана стойност, презаписана с брой дни. Съгласен съм с предписанието (паричен котвен маркер).
  • HIGH-2 на @DiyanaDimitrova (безтекстовото точно-2×) — потвърдена чрез изпълнение (опционна клауза → unchanged_restated; @todorkolev показа и празно-текстовия вариант по подразбиране), и добавям две нови доказателства в подкрепа: (а) собственият тест на PR-а показва правилото да измисля число, което никой не е казвал — за „увеличава се от 15 120 лв. … до 18 900 лв." истината е 18 900, източникът казва 30 240, кодът записва 15 120 и го рендира с чип „коригиран тотал"; (б) правилото противоречи на три собствени документа: заглавния договор на модула („NEVER rewrites … cannot corroborate from text"), коментара в 0007 („never invent a number") и решения §3.3 от план-документа, който за точно-2× изисква валутния текстов маркер (евро|валута|EUR) — планът на автора вече аргументира точно нейната поправка.
  • Трите ѝ средни — потвърдени (таймлайн рендерингът на флагнатите редове; recall загубата на точковия формат — виж НОВА СРЕДНА 2 за втора, по-остра посока на същия парсер; неидемпотентните миграции — виж КРИТИЧНАТА, която е ескалация на същата тема).

КРИТИЧНА — точка 1 на @todorkolev, потвърдена независимо, с две допълнителни последици

Стигнах до същата находка успоредно (възпроизведено тук: no such column: am.value_restated срещу база по 0000_init; deploy.yml няма стъпка за 0006/0007) — той я публикува пръв и анализът му, включително ledger състоянието на живата база, е точен. Добавям две последици, които разширяват blast radius-а отвъд страниците на договори:

  • дневният Worker също пада: refresh-slice.sql:1687-1763 INSERT-ва новите колони в сервираната amendments, така че освен четенето (details.ts) се чупи и самият дневен refresh;
  • ship-domain.mjs строи INSERT списъка си от PRAGMA на работната база → корабирането също прекъсва срещу немигрирана цел.

Съгласен съм с предписанието му (сонда по образеца на 0002, преди Worker деплоя — не migrations apply); допълвам само: 0006 носи два неидемпотентни ALTER-а в един файл, така че или разделяне, или сондата да отказва изрично при частично състояние (1–2 от 3 колони).

НОВА ВИСОКА 1 — мултианексното замърсяване: рестейтнат първи анекс + нормален втори = сервирана стойност, която остава грешна, без нито един gate да я хване

Възпроизведено механично през реалния derive-amendments.sql: подписана 1 000 000; анекс1 удвоен (2 400 000, рестейтнат на 1 400 000); анекс2 идва от емисията върху замърсената база (2 400 000 → 2 760 000, реални +15%). Резултат: current_value = 2 760 000 при истинска стойност 1 760 000 — постоянно надценяване. Никой gate не помага: собственото отношение на анекс2 е 1,15× < 2×, а anchor проверката пада по конструкция (prev.value_after < 2×prev.value_before е невярно точно когато prev е удвоеният). Рестейтването е per-ред и не се пренася по веригата — а бройката „362 коригирани" смята такива договори за оправени, докато сервираната им стойност не е (метриката брои редове, не договори). Тестовете покриват само обратния ред (легитимен анекс1 → удвояващ анекс2), никога този.

Посока: пренасяне на корекцията по веригата (value_before на анекс N от COALESCE(value_after_restated, value_after) на N−1) или anchor разширение („prev беше коригиран, а am.value_before още е суровата му стойност" → флаг), плюс огледален тест и преизмерване на „коригирани" на ниво договор.

НОВА ВИСОКА 2 — reconciliation копието на slice пътя смята гейта върху различни стойности от пълния път

Копията в normalize-raw.sql:970-980 и refresh-slice.sql:1209/:1542 четат FROM raw_amendments prev (сурови стойности); reconciliation блокът refresh-slice.sql:1869-1894 чете FROM amendments prev (сервирани, т.е. рестейтнати) — проверено на този HEAD. Конкретика: анекс1 рестейтнат 2,4M→1,4M; анекс2 1,4M→2,8M. Пълният път дава ok (котвата пада срещу суровите), slice пътят дава annex_total_suspect (котвата минава срещу рестейтнатите) — флагът се обръща между дневния refresh и следващия пълен rebuild. В продукция Worker-ът никога не пуска derive-amendments.sql, така че решаващото копие е точно разминаващото се — а тестовете пускат slice винаги след derive, т.е. продукционната топология не се упражнява от никой тест. Седемте копия на предиката са и без lockstep маркери, въпреки че планът изрично поръча разширяване на #286 образеца. (Уточнение към чистия списък на @todorkolev: „Tier 2 се смята в ingest → няма дрейф" е вярно за Tier 2 — разминаването тук е в Tier-1 reconciliation копието, което е SQL и не се наследява от ingest.)

НОВИ СРЕДНИ

  1. Числовият парсер, втора посока (допълва нейната M-2): фрагментирането на точково групирани числа е и false-positive повърхност, не само recall загуба — „234.567.890,12" се разпада на „234.56"…, а дати („15.03.2026") дават малки числа точно след „на"/„с"; фрагмент, който случайно ≈ малка value_delta (прозорец [100, 999.99]), класифицира грешно. Точков токенизатор + фикстури с дати/точкови числа до тригер-думите.
  2. SQL копията нямат проверката за самосъгласуваност (value_after ≈ value_before + value_delta), която планът §4 изисква за флага — има я само TS класификаторът; ред, за който моделът доказуемо не важи, пак се флагва и изпада от агрегатите.
  3. prev не е ограничен темпорално и в promote не е дедупнат — по-късен анекс или недедупнат staging ред може да „закотви" по-ранен; посоката е безопасна (свръх-флагване), но коментарът „a preceding annex's value_after" е неверен както е написан, в седемте копия.
  4. Ред-срещу-договор противоречие: при изпреварен дубъл (фикстурата 100→200, после 200→130) договорът правилно е ok и сервира 130, но първият ред носи value_suspect = 1 и се рендира „— непотвърден тотал" — две правила, един екран, противоположни присъди.
  5. value_low се унищожава по slice пътя при has_double — preserve клонът се прескача, а re-derive веригата няма value_low рамо; пълният път го пази → флагът се различава между rebuild и refresh. (Дупката предхожда PR-а за has_step10; тук се разширява към нов предикат.)
  6. Без backfill за историческите сервирани редове — маркерите пристигат само чрез пълен rebuild + ship; никъде не е казано, а repo-то има точния прецедент (backfill-current-value-currency.sql + deploy стъпката му).
  7. annex_total_suspect не е регистриран в нито едно канонично изброяване на value_flag: 0000_init.sql, ADR-0003 (дефиниращият документ), core-scope.md, etl.md и — най-осезаемо — describe-schema.ts: асистентът е grounding-нат с изчерпателен списък и генерираните IN-списъци ще изпускат тези договори от бройки, не само от суми.
  8. План-документът се разминава с кода по „решени" точки (освен §3.3 по-горе): „Fix location (decided): raw/derive, не ingest… не мутирай raw_amendments" срещу имплементация в ingest с две нови staging колони (изборът е защитим — суровите стойности остават непокътнати, текстов парс в SQL е нереалистичен — но обръща „решено" без запис защо); никъде не е документирано, че value_treatment е замразен при ingest (промяна на евристиката иска ре-ингест, не re-derive); статус „Draft", стари line референции, несъгласувани бройки (686 в плана срещу „~183" в 0007). Планът да се пренапише или маркира като изместен.
  9. Тестови пропуски (мутационно доказани): редът правило-1-преди-правило-2 е незакован (размяната минава 75/75, а коментарът твърди гаранция); границата на 2× бандата е незакована (разширяване до 1,5× минава зелено — а би позволило рестейтване под value_before); db тестовете сейдват value_treatment на ръка — реалният ingest никога не тече през SQL тестовете, load-eop.mjs е с нулево покритие, 'unchanged_restated' никога не минава през SQL; outsideZop mapper веригата, multi-text join и NBSP числата — без фикстури.

НОВИ НИСКИ

  1. Параграф „модел на заплахата" в заглавието на amendment-total.ts: текстово-управляемият opt-out от флага е data-quality ремонт за небрежен източник, не адверсариален контрол (проверено: не дава нов примитив — пренаписаната стойност е закована в 0,5% от value_delta и е само d или b).
  2. value_delta в сервираната таблица тихо сменя семантиката си (изчислен, вече не суров; NULL при NULL value_before) — единственият консуматор си преизчислява, но промяната е недокументирана.
  3. Асиметрията на genuine_increment: един false positive на голото „с" е по-лош от липса на евристика (връща удвоена стойност в amount_eur, която Tier 1 щеше да изключи) — върви заедно с поправката на нейната HIGH-1.
  4. Рестейтнат ред без FX курс губи чипа „коригиран тотал" — визуално неразличим от празен ред.
  5. outside_zop: NULL се свива до false (прилага безтекстовото правило); тристепенна семантика + брояч restated_unknown_zop. Отворен въпрос: популира ли се isExceptionContract изобщо на ниво анекс ред в реалната емисия? Ако не — guard-ът е инертен на този път.
  6. Бройките в описанието са остарели (75 ingest / 408 db срещу 72/400); currency е мъртво входно поле на класификатора (планът §3.3 всъщност го искаше за валутния маркер).

Проверено и здраво (за баланс)

Никаква инжекция и никакъв нов примитив за контролиращ емисията; EOP пътят типизира value_after_restated коректно като REAL (през baseColumnKind, не SQL_REAL_COLS — проверено); производителността е чиста за сливане (класификаторът строго линеен ~25 ns/знак до 800 KB; всички нови предикати индексирани; +15 ms на дневния batch; един 30-секунден pre-merge check: MAX(COUNT(*)) анекси на договор < ~300 затваря O(k²) наблюдението); natural_key стабилен (hash върху суровите стойности — без #286 риск); флаговете дизюнктни с обща fallback пътека; валутната конверсия взима валутата от печелившия ред (84818 е коректен); follow-up-ите от самопрегледа са реални (tiebreak изравнен, tautology поправена — проверено с мутация, restated чипът рендиран, outsideZop прокаран); двете опровержения от самопрегледа са верни (проверени независимо, с уговорките в НОВА СРЕДНА 3).

Здравна оценка: 6 / 11

Коректност (пълен път) ◐ · Коректност (slice) ✗ · Деплой ✗ · Сигурност ✓ · Производителност ✓ · Тестове ◐ · Документация/план ✗ · Идемпотентност/ре-дерив ◐ · Наблюдаемост ◐ · Обхват ✓ · Честност на валидацията ◐ (метриката брои редове, не договори).

Преди сливане

  1. КРИТИЧНАТА — деплой стъпка за 0006/0007 по образеца на 0002 + разделяне на 0006; без нея всичко останало е мут.
  2. Двете ѝ ВИСОКИ — вече имат съгласувана посока и от нейния преглед, и от собствения план §3.3.
  3. НОВА ВИСОКА 1 — верижно пренасяне или anchor разширение + огледален тест; „коригирани" да се преизмери на ниво договор.
  4. НОВА ВИСОКА 2 — reconciliation копието да се изравни (или разминаването да се документира и тества); slice-без-derive тестово рамо; lockstep маркери за седемте копия.
  5. Средните по преценка (2, 5, 7 и 8 са евтини и режат по клас бъдещи дефекти); ниските — по желание.

Задачата е трудна по същество — свободен текст върху финансови числа — и подходът е правилният: консервативен, слоест, измерен. Критичната е малка като поправка, двете „стари" високи имат готова посока, двете нови са локализирани. С тях това ще е образцовото решение на наистина неприятен дефект. Благодаря — и на @DiyanaDimitrova и @todorkolev за прецизните прегледи преди този.

…or (midt-bg#305)

Addresses the two data-corruption findings on PR midt-bg#307:

- HIGH-1: the total-restatement rule accepted a bare "на <N>", so a
  non-monetary number (days, article nos.) that coincidentally matched
  value_delta could overwrite the published value ("удължава на 200 дни"
  → value 200). Require a monetary anchor bracketing the figure: a value
  keyword before it or a currency unit within ~60 chars after.
- HIGH-2: the exact-2× "unchanged" rule rewrote value_before with no
  text confirmation, silently halving a legitimate ЗОП чл.116 ал.1 т.1
  in-scope +100% (pre-announced option clause) that outsideZop cannot
  model. Require a positive signal (currency re-denomination / "unchanged
  / non-material" phrasing, or the before-value announced as the total);
  otherwise return none and let the arithmetic annex_total_suspect flag
  exclude the row — an honest gap beats a silent corruption.

Also parse dot-thousands + comma-decimal amounts ("1.234,56" / "1,234.56")
so mixed-separator totals are read correctly.
…idt-bg#305)

The contract-page query reads amendments.value_restated/value_suspect and
promote/refresh-slice INSERT value_treatment, but migrations 0006/0007 never
reach the served DB — deploy.yml probes only 0002/0003 and no workflow runs
`d1 migrations apply`. Merging would deploy the Worker against a schema
missing those columns and every contract page (and the ETL write) would fail.

Add a column-probe step mirroring the 0002 pattern that ALTERs only the
missing columns (all three: value_restated, value_treatment, value_suspect),
before the Worker deploys. Additive columns with safe defaults, so no backfill
or completion marker is needed. Document the out-of-ledger application in the
migration headers so a ledger replay does not hit a duplicate-column error.
…idt-bg#305)

For annex_total_suspect (a KNOWN exact 2× double-count) current_value_eur is
NULL, so details.ts fell back to the doubled native current_value and the page
rendered it under an "unverified" label. Show — instead: a known-wrong number
is worse than an honest gap. Adds a currentValueDoubled flag to the value
timeline; the trustworthy signing value is still shown.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Благодаря за двата задълбочени прегледа. Адресирах и трите блокиращи находки; ето какво се промени (нови комити ed87e51, 6693b16, 9e1c49c).

HIGH-1 — голото „на" пренаписва непарично число (@DiyanaDimitrova, @todorkolev)

ed87e51 — total-restatement правилото вече иска паричен котвен маркер около числото: стойностна дума непосредствено преди него, или валутна единица в ~60 знака след него. „…удължава на 200 дни" вече връща none.

Една бележка за котвата: чистото искане на на стойност непосредствено преди числото щеше да счупи и трите реални total-а от корпуса (145652/79382/113291) — там ключовата дума стои далеч преди числото („…ще възлезе на 539 240.00 **лв.**"), а носещият сигнал е валутата след числото. Затова котвата е двустранна (MONEY_BEFORE | MONEY_AFTER), с тест, който пази и трите легитимни срещу регресия.

HIGH-2 — правилото за точно 2× пренаписва без потвърждение (@DiyanaDimitrova, @todorkolev)

ed87e51 — правило 3 вече иска позитивен текстов сигнал (валутна деноминация, или изрично „непроменена/несъществена" формулировка, или самата before-стойност, обявена като нов total). Без сигнал редът връща none и пада към аритметичния annex_total_suspect флаг (изключва, не пренаписва) — честна дупка вместо тихо разполовяване. Обърнати са двата фикстура, които разчитаха на текстово-свободното поведение (84818 празен текст, 103903 „до 18 900"); административният анекс остава unchanged_restated, защото „Несъществени промени" вече е изричният сигнал.

Не тръгнах по стесняване само на ЗОП-предпоставката — outsideZop не е верната ос (вътрешнообхватните изключения по чл.116 ал.1 т.1 не се моделират като булев флаг от емисията), затова текстовият гейт е по-безопасната котва. outsideZop остава като втори, независим guard.

Блокер за деплоя — новите колони не стигат до сервираната база (@todorkolev, точка 1)

6693b16 — добавена стъпка в deploy.yml по образеца на 0002: сонда за колоната + ALTER само при липса, преди Worker-а да сервира. Проверих: promote-amendments.sql и refresh-slice.sql INSERT-ват и value_treatment, така че сондата пази и трите колони (value_restated, value_treatment, value_suspect) — иначе гърми не само четенето, а и ETL записът. Идемпотентността се решава от същата сонда; добавих и бележка в хедърите на 0006/0007, че се прилагат извън ledger-а.

Средни

  • Удвоената стойност на страницата (9e1c49c): за annex_total_suspect currentEur вече е празно () с точна бележка, вместо етикетирано 2× число; стойността при сключване (достоверна) остава видима. Нов тест в details.test.ts.
  • 1.234,56 (ed87e51): normalizeBgNumber вече разчита смесени разделители (BG и US подредба). Промяната е консервативна — активира се само когато и двата разделителя присъстват, така че едносепараторните числа от корпуса не се влияят.
  • Идемпотентност на миграциите: покрита от сондата по-горе.

Тестове: @sigma/ingest 80 (+5), @sigma/db 409 (+2), tsc чист за db/api-contract/web, prettier чист. Финдингите за SQL инжекция/дрейф, които потвърдихте като чисти, не са пипани.

…ion (midt-bg#305)

Two correctness findings from review, both on the annex_total_suspect gate:

- NEW HIGH 1 (multi-annex chain contamination): the double-count correction
  is per-row and does not propagate down a chain. A restated prior annex
  (doubled → corrected down) leaves a later annex still computed by the feed
  on the raw, doubled base; its own step ratio is legitimate (<2×) so the
  arithmetic gate missed it and the prior is text-treated (excluded), yet
  current_value inherited the doubled total. Add a branch that flags the
  driving annex whose value_before ties to a prior annex's raw value_after
  where that prior was restated lower → signing fallback (honest exclusion
  beats a served overstatement). Full-corpus per-chain value_before
  propagation (the exact correction) is deferred to a follow-up.

- NEW HIGH 2 (slice reconciliation parity): the reconciliation reads the
  cumulative served `amendments` (whose value_after is restated) while the
  full path anchors on raw values, so a restated prior annex flipped the
  "prev not itself a double" test and the flag flipped between the daily
  slice and the next full rebuild — and production only runs the slice copy.
  Restrict the anchor to non-restated prevs (prev.value_restated = 0), whose
  served value_after equals raw, reproducing the full-path decision without
  losing cross-window history. The slice cannot see a restated prev's raw
  value_after, so the contamination detection there is a documented
  conservative approximation; exactness returns on the next full rebuild.

Also add the value_after ≈ value_before + value_delta self-consistency guard
(NULL-tolerant) to the gate copies, matching the TS classifier's precondition.

Tests exercise both fixes across the full and slice paths (real scripts).
…midt-bg#305)

The flag was missing from the assistant grounding (describe-schema.ts) and the
0000_init schema comments, so generated IN-lists could drop those contracts
from counts (not just sums). List it alongside the other value_flag verdicts.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Благодаря, @nikimilenkov — прегледът по строгия протокол хвана две реални неща, които предишните два не бяха повдигнали. Адресирах и двете нови ВИСОКИ + двете евтини средни (M2, M7); новите комити са 34d93c8 и 46fcb07 (върху вече качените ed87e51/6693b16/9e1c49c за трите по-ранни блокера).

НОВА ВИСОКА 1 — верижно замърсяване

Потвърдих механично: value_before никога не се съгласува по веригата, така че по-късен анекс върху замърсената (сурова, удвоена) база минава под ≥2× гейта, а рестейтнатият предходник е изключен. Добавен нов клон в annex_total_suspect гейта (normalize-raw.sql + slice reconciliation): засича задвижващия анекс, чиято value_before съвпада със суровата value_after на предходен анекс, който сам е бил рестейтнат надолу → fallback към signing. Пълното per-chain пренасяне на value_before (точната корекция вместо изключване) го оставям за отделен PR — то пренаписва публикувани стойности за всеки многоанексен договор и иска пълнокорпусен before/after одит (+ известния sigma-dev schema drift блокира e2e от клон). Метриката „коригирани" — съгласен, брои редове; отбелязано за преизчисление на ниво договор в follow-up-а.

НОВА ВИСОКА 2 — паритет на reconciliation копието — с една корекция към предписанието

Потвърдих флипа. Но при имплементацията излезе, че предложеният one-token fix (amendments prevraw_amendments prev) е неверен за slice пътя: header-ът на refresh-slice.sql (редове 30, 68-71) изрично казва, че там raw_amendments държи само заредения прозорец — reconciliation-ът чете кумулативната сервирана таблица нарочно, защото предходен анекс от по-ранен прозорец липсва в raw_amendments. Смяната на таблицата щеше да счупи cross-window вериги (под-флагване).

Истинската ос на разминаването е prev.value_after < 2*prev.value_before: при рестейтнат предходник сервираната value_after е коригираната (по-ниска) стойност, така че тестът се обръща. Затова: AND prev.value_restated = 0 на анкера — той изключва точно редовете, при които сервирано ≠ сурово, и възпроизвежда решението на пълния път без да губи кумулативната история. Тъй като сервираната таблица не пази суровата value_after на рестейтнат предходник, засичането на ВИСОКА 1 по slice пътя е документирана консервативна апроксимация; точността се връща на следващия пълен rebuild. Добавен и тест с реалния flip fixture (пълен vs slice дават еднакъв флаг).

Средните

  • M2 (a ≈ b + d самосъгласуваност): добавена, NULL-толерантна (value_delta IS NULL OR …), на всичките копия на гейта — ред, за който моделът доказуемо не важи, вече не се флагва.
  • M7 (annex_total_suspect в изброяванията): добавен в describe-schema.ts (двете места) и коментарите на 0000_init.sql, за да не изпадат тези договори от генерираните IN-списъци/бройки на асистента.

Отбелязани за follow-up (по преценка на поддържащите)

Per-chain value_before пренасяне (точната корекция за ВИСОКА 1); byte-identical lockstep маркери + drift тест за седемте копия на предиката (сега паритетът се пази функционално от тестовете, минаващи и по двата пътя); no-derive slice топология като отделно тестово рамо; и по-малките средни/ниски (числовия токенизатор като false-positive повърхност, backfill за историческите редове, план-документа). Мога да ги отворя като issue-та с плановете, ако искате.

Тестове: @sigma/db 413 (+4 нови — верижно замърсяване и reconciliation паритет по двата пътя), @sigma/ingest 80, tsc/prettier чисти. Благодаря пак за прецизността — особено за наблюдението, че продукционният Worker пуска само разминаващото се копие; то беше ключът към правилната поправка.

@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Валидация на NEW HIGH 1 срещу живия корпус (sigma-dev D1)

Проверих на реалните данни колко договора реално са засегнати — не е теоретичен ръб.

Първо потвърждава и блокера за деплоя: живата sigma-dev няма колоните value_restated/value_treatment/value_suspect и annex_total_suspect се среща 0 пъти в contracts.value_flag — тоест целият #305 още не е сервиран там (независимо потвърждение на CRITICAL/Blocker A). Затова amendments.value_after там е суровата стойност — точно pre-fix състоянието, идеално за измерване.

NEW HIGH 1 — популацията, която само новият клон хваща (договор ok, чиято current_value е задвижена от легитимен по-късен анекс (<2× стъпка), възседнал предходен ≥2× дубъл, който е изместен — така старият гейт, вързан за current_value, го пропуска):

метрика стойност
Замърсени договори (старият гейт пропуска, новият хваща) 37
…с current_value ≥ 2× signing (явно надценени) 33
Сумарна сервирана current_value ~210 млн. лв.
След fallback към signing ~80 млн. лв.
Надценяване, което поправката маха ~130 млн. лв.

Конкретен пример 00752-2021-0007: анекс1 утроява 13.58М→40.73М (дубъл, който #305 рестейтва), анекс2 добавя легитимни +16% върху замърсената база 40.73М → сервирана current_value = 47.06М. Утрояващият анекс не задвижва current_value (40.73М ≠ 47.06М), затова днес е ok; новият клон го флагва → 47.06М пада към signing 13.58М.

Честни уговорки:

  • 37 е горна граница по формата. Новият клон гърми само когато предходният дубъл реално е рестейтнат (value_after_restated IS NOT NULL) — което зависи от текста на основанието, а сервираната база не носи текстова класификация, тъй че точното подмножество не се разделя оттук. Flag-only предходници (нерестейтнати) са свързан остатък — покрит от отложения follow-up за per-chain пренасяне на value_before.
  • NEW HIGH 2 (slice-vs-full паритет) не се мери от сервирания изход — то е за това коя стойност чете reconciliation-ът по време на ETL. Валидира се от новия parity тест по двата пътя, не от заявка към D1.
  • Не мога да пусна самата поправена флаг-логика срещу D1 (D1 е изходът на ETL, не стейджингът). Категоричното доказателство е сюитата от 413 теста върху реалните SQL скриптове; тази D1 проверка показва, че поправката цели реална, немалка популация (~130 млн. лв. надценяване в 37 договора).

(Методология: заявките са срещу D1 sigma-dev, акаунт b2abee…353d.)

D1 validation against the live corpus surfaced 46 contracts (~96M EUR) whose
exact double-count sits on an "orphan" value_before — one tying neither the
signing value nor any prior annex (e.g. ticket example 84818: an annex reports
76.77M → 153.54M on a base unrelated to the contract). The arithmetic gate's
anchor (signing OR a legit prior total) can't reach them, and midt-bg#307's HIGH-2
gating correctly declines to text-freely halve them — so they were served at
the doubled value, neither flagged nor corrected.

Relax the gate's anchor with a third arm: an EXACT single-step 2× (a legal
+100% in one amendment is impossible under ЗОП чл.116) flags → signing fallback
even on an orphan base. It EXCLUDES only — it never rewrites the value (that
stays text-gated per HIGH-2). An orphan guard (NOT EXISTS a prior annex tying
value_before) leaves a legitimate compounding-doubling chain untouched, exactly
as the legit-prior arm does. Applied to all gate copies (full, slice, per-row).

On the live corpus this lifts contract-level detection to ~360 (337 from `ok`),
84818 among them. Test: orphan exact-2× flagged; non-exact orphan jump stays ok.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

End-to-end валидация на #305 + PR срещу живия sigma-dev D1

Метод: D1 е изходът на ETL, не стейджингът, и в момента е pre-#305 (няма колоните value_restated/value_treatment, нула annex_total_suspect — което самó потвърждава Blocker A). Затова amendments.value_after там е суровата удвоена стойност — точно pre-fix състоянието. Измерих популацията на всяка находка и пуснах фиксираната аритметична логика като SQL; текстовите пътища (искат TS класификатора) са отбелязани отделно.

1. Проблемът от #305 се възпроизвежда

Тикет (rebuild 12.08) Живо sigma-dev
анекси с ръст: 7 335 7 306
≥100% заподозрени: 686 687
точно +100% дубли: 199 281 (по-нов, по-голям снапшот: 31 597 анекса)

2. Покритие на поправката — скок

Фиксираният аритметичен гейт флагва 360 договора, 337 от които са ok днес → детекцията на ниво договор скача от ~20 → ~360, изваждайки удвоени стойности към signing fallback. NEW HIGH 1 (верижно замърсяване) добавя 37, които старият гейт пропуска (~130 млн. лв.).

3. Трите примера от тикета

Запис Резултат
189325 (77М→154М, „от лева в евро") ✅ хванат — валутна деноминация → unchanged_restated/гейт → 77М
145652 (442К→981К дубъл) ✅ хванат от гейта → signing fallback 442К
84818 (76.77М→153.54М точен дубъл, „курсове…преструктурират") сега хванат — виж §4

4. Находка от D1 → нова поправка (03b8384)

84818 разкри дупка: точният му 2× стои на сирашка база (76.77М не се връзва нито към signing 113.4М, нито към предходен анекс). Старият анкер не го стига, а #307 HIGH-2 гейтингът (правилно) отказва да го разполови без текст → сервираше се удвоен. Мащаб: 46 договора, ~96 млн. EUR.

Добавих трети арм към анкера: точен единичен 2× (легален +100% в един анекс е невъзможен по чл.116 ЗОП) флагва → signing fallback дори на сирашка база. Само изключва — никога не пренаписва (текст-фрий рестейтът остава гейтнат, HIGH-2). Orphan guard (NOT EXISTS предходен анекс, връзващ value_before) оставя легитимните компаундиращи вериги непокътнати. На живия корпус това вдига детекцията до 360 (337 от ok), вкл. 84818.

5. Статус на всяка находка (валидирано на D1 където е измеримо)

Находка Митигирано Доказателство
Blocker A (колоните не стигат до сервираната база) D1: колоните липсват + 0 annex_total_suspect — точната повреда; probe я решава
HIGH-1 (голото „на") ✅ (тест) ingest текст логика — не се вижда от изхода; 17 unit теста
HIGH-2 (текст-гейт 2×) ✅ (с компромис) §4 — държи се както поискахте; извади 46-те сирашки дубъла, вече покрити
NEW HIGH 1 (верижно замърсяване) 37 реални договора, ~130 млн. лв. (измерено)
NEW HIGH 2 (reconciliation паритет) ✅ (тест) ETL-вътрешен флип — не се вижда от изхода; parity тест
84818-class (сирашки точен дубъл) ✅ (нова поправка) 46 договора / ~96 млн. EUR; 84818 сега хванат
Display / числов формат / M7 / M2 ✅ (код/тест) презентация & ingest — не се виждат от сервираните редове

Тестове: @sigma/db 415, @sigma/ingest 80, tsc/prettier чисти. Всичко от прегледите е адресирано; D1 проверката показа реален остатък (84818-class), който вече е поправен.

(Заявки срещу D1 sigma-dev, акаунт b2abee…353d.)

@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Пълен тест на #305 + PR срещу живия sigma-dev D1

Метод: D1 е изходът на ETL и в момента е pre-#305 (липсват value_restated/value_treatment, 0 annex_total_suspect — потвърждава Blocker A), значи amendments.value_after там е суровата удвоена стойност — точното pre-fix състояние. Измерих всяка количествена претенция от тикета и всяка находка, която е наблюдаема от сервираните данни (вкл. текстовите — D1 носи description). ETL-вътрешните (NEW HIGH 2 паритет) са отбелязани като тест-валидирани.

A. Проблемът от #305 — възпроизведен

Претенция (rebuild 12.08) Живо sigma-dev
общо анекси: 26 921 31 597 (по-нов снапшот)
с ръст: 7 335 7 306
≥100% заподозрени: 686 687
точно +100% дубли: 199 274
текущи флагове хващат ~20 11 value-флагнати (5 annex_suspect + 5 review + 1 value_suspect)
незасечени 479 заподозрени договора са ok днес

Договори, чиято current_value е реално надута от дубъл: 397 (ok), сервиращи ~506 млн. EUR удвоена стойност.

B. Покритие на поправката

  • Фиксираният аритметичен гейт флагва 360 договора, 337 от ok → детекция на ниво договор ~20 → ~360.
  • Тези 337 свалят ~506 млн. → ~272 млн. EUR (signing fallback) — маха ~234 млн. EUR фиктивна стойност.
  • Трите примера: 189325 ✅ (валута→restate 77М), 145652 ✅ (гейт→signing 442К), 84818 ✅ (сега хванат).

C. Всяка находка, тествана на D1

Находка Митиг. Доказателство от D1
Blocker A колоните липсват + 0 annex_total_suspect — точната повреда
HIGH-1 (голото „на") 12 800 анекса с непарично „на N дни/месец" + 694 с „чл. N на ЗОП" — реалната рискова повърхност, която money-anchor-ът пази; фиксът гърми само на 9 599-те парични
HIGH-2 (текст-гейт 2×) от 274 точни дубъла само 12 носят restate-сигнал (1 валута + 11 „несъществен") → пренаписват се; другите ~262 падат към флаг — точно консервативността, която поискахте
NEW HIGH 1 (верижно замърсяване) 37 договора, ~130 млн. лв.
NEW HIGH 2 (reconciliation паритет) ✅ (тест) ETL-вътрешен флип — не се вижда от изхода; parity тест
84818-class (сирашки точен дубъл) 46 договора / ~96 млн. EUR; 84818 сега хванат
Число 1.234,56 7 точни + 120 multi-dot суми в описанията — реален (малък) recall
M2 (само-съгласуваност) 0 заподозрени с несъгласувана делта днес — чисто защитен guard, не мени нищо сега (честно)
Display / M7 (изброявания) ✅ (код) презентация/asистент grounding — не се вижда от сервираните редове

D. Честни остатъци

  • ~60 договора (397 надути − 337 хванати) са non-exact сирашки дубли (напр. 2.5× на несвързана база), които консервативният фикс нарочно оставя — може да са легитимни +100% или искат текст. Кандидати за верижното пренасяне (follow-up).
  • Сляпо петно (тикетът го отбелязва): дубли под 100% (нова обща стойност < старата) дават ръст <100% и не се засичат по подписа — не се мери от суровите данни.

Заключение

Проблемът от #305 е реален и се възпроизвежда (687 заподозрени, ~506 млн. EUR надути в 397 договора). PR-ът вдига детекцията от ~20 на ~360 договора, а D1 проверката потвърди всяка находка и извади един реален остатък (84818-class, ~96 млн. EUR), който вече е поправен. Тестове: @sigma/db 415, @sigma/ingest 80, tsc/prettier чисти.

(Заявки срещу D1 sigma-dev, акаунт b2abee…353d.)

cefothe added a commit to cefothe/sigma that referenced this pull request Aug 13, 2026
Move the annex→contract value resolver out of derive-amendments.sql into
its own script that runs BEFORE the prefer-EOP dedup, full-derive path
only. Running after the dedup resurrected OCDS twins (annex_count=2 on a
one-annex contract) and tripped the midt-bg#303 twin gate (todorkolev #1); the
slice path's windowed raw_contracts can't answer "unique on the
procedure" so it is gated off (nikimilenkov HIGH 1).

- dedup cumulative raw_contracts candidates before matching, else a
  contract in N daily buckets reads as n_match=N (nikimilenkov HIGH 2)
- one group rule: refuse a (unp, annex-number) group on any internal
  contradiction (ambiguous member or disagreeing anchors), voiding even
  its direct hits; value-less members inherit the agreed target
  (todorkolev #2, nikimilenkov MEDIUM 1 & 2)
- null-tolerant contractor-EIK guard; explicit currency both sides
  (MEDIUM 5, LOW 1); index the resolve scratch table (LOW 4)
- provenance: contract_number_raw + link_method through staging, served
  amendments (migration 0006), and promote; the raw number also keys the
  amendment natural_key so a resolved row never collides with a native
  annex sharing document_number on the target (MEDIUM 3, MEDIUM 4)
- sweep the resolve scratch table in transient cleanup (LOW 2)
- fix comments and plan §3/§4/§5 (8348→9348, order, slice gating, the
  midt-bg#307-flag merge-order dependency) (MEDIUM 6)

Tests run the real resolver+derive composition: twin-ordering, group
contradiction, cumulative-dup, value-less inherit, EIK, exact-cent
tolerance, gate, idempotency, natural-key collision, provenance.
@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах наново на 03b8384. И трите ми точки са затворени - проверих ги, не ги приемам по описание:

  • голото „на": 'Срокът на договора се удължава на 200 дни.' вече връща none вместо total_restated: 200, а контролата 'Новата обща стойност възлиза на 200 лв.' продължава да се пренаписва. Точно каквото трябва.
  • безтекстовото 2×: празен текст и несвързан административен текст вече дават none. Пренаписването иска положителен сигнал, а редовете падат към флага, вместо да се разполовяват тихо. Това е верният завой - изключвай, не пренаписвай без доказателство.
  • миграциите: стъпката сондира трите колони поотделно, ALTER-ва само липсващите, третира неясен отговор като фатален и стои на ред 225, тоест преди деплоя на Worker-а на ред 277. Точно по образеца на валутната стъпка.

Тестовете минават: ingest 80, db 414 (единственото падане е предсъществуващият счупен глобален wrangler в средата).

Остават две тесни дупки в самите нови пазачи. И двете са от същия клас - пренаписват публикувана стойност - и за двете предложението е проверено, че не вали нито един от вашите 80 теста.

1. Паричната котва се задоволява с валута другаде в изречението

MONEY_AFTER търси валутна единица в следващите ~60 знака, тъй че всяко изречение, което споменава и срок, и пари, минава:

'Срокът се удължава на 200 дни, стойността остава 100 лв.'
  → total_restated: 200      (200 е брой ДНИ)

'Срокът за изпълнение на договора се удължава на 200 дни, без промяна в договорената сума в лв.'
  → total_restated: 200

А това е много честа формулировка в основанията - срок и стойност в едно изречение.

Проверено предложение: вето, когато веднага след числото стои непарична мерна единица. Добавих един ред преди проверката за котва:

if (/^\s*(?:дни|дн\.|месец\p{L}*|години|год\.|броя|бр\.|%|процент\p{L}*)/iu.test(after)) continue;

И двата случая горе стават none, реалният 539 240.00 лв. продължава да се пренаписва, и ingest остава 80/80.

2. Голото „в евро" пуска клауза за валута на плащане

RESTATE_UNCHANGED_CTX съдържа както лев\p{L}*\s+в\s+евро, така и голо в\s+евро. Второто хваща текстове, които не казват нищо за непроменена стойност:

'Плащанията по договора се извършват в евро по сметка на изпълнителя.'
  → unchanged_restated: 250000      (разполовява реално удвояване)

Голата алтернатива е и излишна: вашата собствена фикстура за 189325 е „…се променя от лева в евро", която първата алтернатива вече хваща. Махнах голото в\s+евро и:

  • случаят с плащането става none;
  • деноминацията и „не се променя" продължават да се пренаписват;
  • ingest остава 80/80, включително тестът за 189325.

Тоест това е една махната алтернатива без загуба на покритие.

Останало

Двете дупки са тесни, но са в кода, чиято единствена работа е да пази от точно този клас грешка, и цената при грешка е несиметрична - подценен реален договор, невидим за читателя. С тези две стеснения от моя страна PR-ът е готов.

… payment-currency clauses (midt-bg#305)

- MONEY_AFTER anchored a total on any currency token in the 60-char window, so
  '...удължава на 200 дни, стойността остава 100 лв.' restated value_after to a day
  count. Veto the figure when a non-monetary unit (days/months/years/count/%)
  immediately follows it, before the currency anchor is consulted.
- RESTATE_UNCHANGED_CTX matched a bare 'в евро', so a payment-in-euro clause
  ('Плащанията...се извършват в евро...') halved a real +100%. Drop the bare
  alternative; the 'X в евро' re-denomination form (189325) already covers the fixture.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

И двете тесни дупки са затворени в c199202.

1. Паричната котва + непарична мерна единица. MONEY_AFTER вече не се задоволява с валута другаде в изречението — добавен е вето NON_MONEY_UNIT_AFTER (дни|дн.|месец…|години|год.|броя|бр.|%|процент…), който отхвърля числото, когато непарична единица стои непосредствено след него, преди проверката за котва. Двата примера (…удължава на 200 дни, стойността остава 100 лв. и вариантът с „…без промяна … в лв.") вече дават none; реалният 539 240.00 лв. продължава да се пренаписва.

2. Голото „в евро". Махнато от RESTATE_UNCHANGED_CTX. Остава само пренаписващата формулировка лев… в евро, която вече покрива фикстурата 189325 (…се променя от лева в евро). Клаузата за валута на плащане (Плащанията…се извършват в евро…) вече е none.

Добавени са и два теста точно за тези случаи.

Тестове: ingest 82/82 (80 предишни + 2 нови), amendment-total.test.ts 20/20.

Проверка срещу реалния корпус (D1 sigma-dev, 31 597 анекса). Пуснах класификатора стар-срещу-нов над всичките 581 реда, за които моделът за двойно броене изобщо е приложим (самосъгласувани, 2b≤a<10b):

  • Нов класификатор: 83 total_restated, 26 unchanged_restated, 10 genuine_increment, 462 none.
  • Разлика стар↔нов: 0 реда. Нито едно реално пренаписване не се губи.
  • От 22-та реда с exact-2× и „евро" в текста — 0 разчитаха на голото „в евро".

Двете стеснения са строго subtractive (вето + махната алтернатива): могат само да превърнат пренаписване в none, никога да измислят ново. Единствената уговорка е, че сервираната amendments.description носи само едно от трите основание-полета; пълна проверка след деплой, когато ETL попълни value_treatment.

С това PR-ът е готов от моя страна.

cefothe added a commit to cefothe/sigma that referenced this pull request Aug 13, 2026
midt-bg#306)

The value-anchor resolver only ran on the full derive, so the cron — which
runs only refresh-slice.sql — left new namespace-mismatched EOP annexes
unlinked between full rebuilds. Add a corpus-safe resolver inside
refresh-slice.sql: candidate contracts come from the served `contracts`
corpus UNIONed with the window's raw_contracts, so "unique on the procedure"
is asked corpus-wide, not just within the window. It runs before the
prefer-EOP dedup (same ordering the full path uses before derive-amendments),
carries contract_number_raw + link_method provenance through the served
amendments promotion, and keys the amendment natural_key on the raw annex
number so slice and full keys agree. Resolved prior-window targets land in
refresh_touched_contracts via the existing amendment touch join.

Renumber 0006_amendment_provenance.sql -> 0008 to avoid the migration-number
collision with midt-bg#307 (0006/0007), assuming midt-bg#307 merges first.

Addresses PR midt-bg#308 review (todorkolev): daily-path requirement + migration renumber.
@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах на 5d1fcfd. Втората дупка е затворена изцяло, първата - само буквално.

Затворено: голото „в евро"

Проверих го, не го приемам по описание:

'Плащанията по договора се извършват в евро по сметка на изпълнителя.'
  преди 5d1fcfd → unchanged_restated: 250000
  сега          → none                          ✔

'Стойността се променя от лева в евро, без промяна в предмета.'
  сега          → unchanged_restated: 250000    ✔ (деноминацията се пази)

Точно това искахме: махната алтернатива без загуба на покритие.

Отворено: ветото хваща само низа, който съобщих, не класа

NON_MONEY_UNIT_AFTER е закотвено с ^ върху отрязъка веднага след числото, тъй че всяка дума между числото и мерната единица го обезсилва. А в българските анекси мерната единица почти никога не идва гола - срокът се пише „работни дни" или „календарни дни".

Пуснах ги през истинския classifyAmendmentValue (value_before 100, value_after 300, value_delta 200 - формата, при която правило 2 работи):

текст сега
…се удължава на 200 дни, стойността остава 100 лв. none
…се удължава на 200 работни дни, стойността остава 100 лв. total_restated: 200
…се удължава на 200 календарни дни, стойността остава 100 лв. total_restated: 200
…се удължава на 200 работни дни, без промяна в договорената сума в лв. total_restated: 200
…се удължава на 200 к.д., стойността остава 100 лв. total_restated: 200
…се удължава на 200 (двеста) дни, стойността остава 100 лв. total_restated: 200
Площта се увеличава на 200 кв.м, стойността остава 100 лв. total_restated: 200

Тоест договор за 300 лв. се публикува като договор за 200 лв., защото 200 е брой работни дни. „Работни дни" и „календарни дни" са по-честите формулировки от голото „дни" - ветото минава покрай тях.

Същото важи и за новия тест: той заковава дословно изречението, което дадох, а не класа грешка. Затова и не хваща варианта с една дума повече.

Проверено предложение - пуска една незадължителна дума (и незадължителна скоба за изписаното с думи число) преди единицата, и разширява списъка:

const NON_MONEY_UNIT_AFTER =
  /^\s*(?:\([^)]*\)\s*)?(?:\p{L}+\s+)?(?:дни|дн\.|к\.\s?д\.|р\.\s?д\.|месец\p{L}*|години|год\.|броя|бр\.|кв\.?\s?м|куб\.?\s?м|тона|литра|%|процент\p{L}*)/iu;

С него всичките седем реда горе стават none, а ingest остава 82/82.

Проверих и обратната посока - че по-широкото вето не изяжда истински парични изрази:

'Общата стойност на договора възлиза на 200 лв. без ДДС.'                    → total_restated: 200 ✔
'Общата стойност на договора възлиза на 200 лева.'                           → total_restated: 200 ✔
'Новата обща стойност възлиза на 200 лв. за срок от 12 месеца.'              → total_restated: 200 ✔
'Общата стойност се увеличава на 200 лева месечно.'                          → total_restated: 200 ✔
'Общата стойност възлиза на 200 лв. и срокът се удължава с 30 работни дни.'  → total_restated: 200 ✔

Дори да сгреши, посоката е безопасната: ветото води до none, редът пада към аритметичния флаг и се изключва - вместо да се публикува подменена стойност. Точно логиката, която сами приложихте при безтекстовото 2×.

Обобщение

Едно нещо остава, и е на един ред разстояние. С това вето PR-ът е готов от моя страна.

cefothe added a commit to cefothe/sigma that referenced this pull request Aug 14, 2026
…/full on zero-value contracts

Two follow-ups from PR midt-bg#308 review (todorkolev):

- Blocker: refresh-slice.sql writes contract_number_raw/link_method into served
  `amendments`, but nothing added those columns to the deployed DB, so the first
  cron after release would crash. Add an idempotent deploy.yml step (probe
  pragma_table_info, ALTER only the missing columns, malformed response fatal)
  before the Worker deploys — mirroring the 0002/midt-bg#307 pattern. The base schema is
  created out-of-band, so `d1 migrations apply` can't be used.
- Discrepancy: the slice resolver asked "is there a matchable candidate" (which
  requires signing_value > 0), so an annex pointing BY NUMBER to a zero-value
  contract was treated as namespace-mismatched and could be value-linked to a
  neighbour — diverging from the full path. Ask a value-agnostic
  all_contract_numbers CTE instead, so a by-number match always links by number.
  Regression test added.
- Nit: candidate dedup now orders by source-day then id (source DESC, id DESC),
  matching normalize-raw and the comment.

Addresses PR midt-bg#308 review (todorkolev): deploy blocker + zero-value discrepancy + ordering nit.
…idt-bg#305)

NON_MONEY_UNIT_AFTER was anchored on the slice immediately after the
figure, so any word between the number and its unit ("200 работни дни",
"200 календарни дни") slipped past the veto and let a downstream currency
token restate the contract value to a day count. Allow one optional
adjective word (and an optional spelled-out number in brackets) before
the unit and extend the unit list (к.д., р.д., кв.м, куб.м, тона, литра).

Tests now assert the error class (qualified units, brackets, area/volume)
plus the reverse direction — the wider veto must not swallow a genuine
monetary total. @sigma/ingest 84/84, tsc + prettier clean.
@cefothe

cefothe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Готово на 36dd21e — ветото вече хваща класа, не низа.

NON_MONEY_UNIT_AFTER е с една незадължителна дума (плюс незадължителна скоба за изписаното с думи число) преди единицата, и списъкът е разширен с к.д., р.д., кв.м, куб.м, тона, литра:

const NON_MONEY_UNIT_AFTER =
  /^\s*(?:\([^)]*\)\s*)?(?:\p{L}+\s+)?(?:дни|дн\.|к\.\s?д\.|р\.\s?д\.|месец\p{L}*|години|год\.|броя|бр\.|кв\.?\s?м|куб\.?\s?м|тона|литра|%|процент\p{L}*)/iu;

Всичките седем реда от прегледа стават none. Тестът вече заковава класа, не изречението: новият случай минава списък от квалифицирани единици (работни/календарни дни, скоба, к.д., кв.м, куб.м) → none, плюс обратна проверка че по-широкото вето не изяжда истински парични тотали (те остават total_restated). @sigma/ingest 84/84, tsc + prettier чисти.

Валидация срещу реалния корпус (sigma-dev D1)

Пуснах стария срещу новия classifyAmendmentValue върху цялата удвоена лента:

  • Самосъгласувана удвоена лента: 581 реда.
  • OLD vs NEW: 0 реда се променят. Разпределението е идентично преди и след — total_restated 83 / unchanged_restated 26 / genuine_increment 10 / none 462.
  • От тях 36 носят квалифицирана единица (работни дни, к.д., кв.м…) — и те непроменени.

Тоест поправката затваря класа, който посочи, без да мести нито една публикувана стойност, и по-широкото вето не глътна нито един истински тотал (total_restated остава 83). Точно безопасната посока.

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах на 36dd21e. Готово от моя страна.

Ветото е разширено и, което е по-важно, тестът вече заковава класа, а не изречението, което дадох - осемте варианта с работни/календарни дни, к.д., р.д., изписаното с думи число и мерките за площ и обем. Плюс обратната посока с петте истински парични израза. Точно това липсваше предния път.

Проверих го не само на измислени изречения, а на реалния корпус. Изтеглих от stage всички 564 реда, които имат формата на двойното броене (value_after = value_before + value_delta, 2b ≤ a < 10b, с текст), и ги прекарах през класификатора на трите състояния:

глава total_restated genuine_increment unchanged_restated none
03b8384 (преди фикса) 83 10 26 445
5d1fcfd (тясното вето) 83 10 26 445
36dd21e (широкото вето) 83 10 26 445

Нула разлики. Тоест разширеното вето не изяжда нищо в днешния корпус - това беше рискът, който исках да изключа, преди да го предложа за мърдж.

Обратната страна на същото число, честно казано: класът с брой дни не се среща в днешния корпус. Пазачът е превантивен - пази бъдещи редове и класа като цяло, не поправя жив дефект. Държа да го кажа, за да не изглежда находката ми по-голяма, отколкото е.

Проверих и едно семейство от пренаписаните редове, за да не приема механиката по описание - 17 реда с 1,38 → 2,90, пренаписани на 1,52:

„…максималният размер на средствата за компенсиране на километър от общия пробег … се променя на 1,52 лв./км. с ДДС."

Обявената нова стойност наистина е 1,52, а 2,90 = 1,38 + 1,52 е точно двойното броене. Пренаписването е вярно.

ingest 84/84.

Остава само номерът на миграцията - #309 също носи 0006 и 0007. Ако този влезе пръв, запазва ги както са; #309 се мести.

@todorkolev todorkolev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Одобрявам на 36dd21e. И двете дупки от предния кръг са затворени, а тестът вече заковава класа, а не изречението, което дадох.

Проверено на реалния корпус: всичките 564 реда с формата на двойното броене класифицират еднакво преди и след разширеното вето - нула съжаления. Спот-проверих и едно семейство пренаписани редове срещу текста на анекса; пренаписването е вярно.

ingest 84/84.

@todorkolev
todorkolev merged commit 9999ad5 into midt-bg:main Aug 14, 2026
5 checks passed
todorkolev added a commit that referenced this pull request Aug 14, 2026
Стъпката „Apply amendment restated/suspect columns" от #307 падна на първото си истинско пускане и нямаше как да мине - нито на тази база, нито на никоя.

Заявката именуваше колоните has_restated / has_treatment / has_suspect, а read_flag търси реда по низа, с който е извикана ensure_column, тоест value_restated / value_treatment / value_suspect. Object.hasOwn връща false за всяка от трите, сондата чете това като нечетим отговор и излиза с 2, което case-ът третира като фатално.

Понеже стъпката стои преди деплоя на Worker-а, деплоят до stage спираше на нея. Сайтът не беше засегнат - нищо не е било разгърнато (стъпки 12-14 прескочени), stage връщаше 200 с изданието отпреди #307.

Псевдонимите стават самите имена на колоните, плюс коментар защо не могат да бъдат описателни. Проверено срещу истинската база: заявката връща {value_restated: 0, value_treatment: 0, value_suspect: 0}, а сондата дава статус 1 (добави) при липсващи и 0 (има ги) при налични - никога 2.

Пропускът е показателен: deploy.yml не се изпълнява преди мърдж (пуска се само при push към main), нито един тест не го чете и в CI няма линтер за workflow файлове, тъй че първото изпълнение на всяка промяна в деплой стъпка е винаги СЛЕД мърджа. Изнасянето на сондата в проверим скрипт е отделна следваща стъпка.
todorkolev added a commit to cefothe/sigma that referenced this pull request Aug 14, 2026
Разрешени конфликти след midt-bg#307 (двойно броене в анексите) и midt-bg#310.

deploy.yml — двете колони на midt-bg#306 се сливат в стъпката на midt-bg#307, вместо да се
държи втора стъпка. Точно каквото искаше бележката в самия midt-bg#308: „If midt-bg#307
merges first, fold these two columns into its provenance step instead of
keeping this one." Една сонда, един механизъм; втора ръчно написана стъпка е
още един шанс за грешката, която midt-bg#310 трябваше да оправи. Заявката вече пита
за пет колони, а ensure_column се вика пет пъти. Проверено срещу живата база:
{value_restated: 1, value_treatment: 1, value_suspect: 1, contract_number_raw: 0,
link_method: 0} — трите на midt-bg#307 ги има, двете на midt-bg#306 ще се добавят.

promote-amendments.sql и refresh-slice.sql — списъкът с колони в INSERT-а
събира и двете страни: contract_number_raw/link_method от midt-bg#306 и
value_restated/value_treatment/value_suspect от midt-bg#305. Редът отговаря на
SELECT-а, който git вече беше слял правилно.

Тестове — всяко от двете подавания добавяше своята миграция към веригата, с
която строи схемата. Сега всеки файл, който сервира amendments, прилага и
0006/0007, и 0008; иначе скриптовете падат на липсваща колона от другата
страна. Това важи в двете посоки: файловете на midt-bg#305 получиха 0008, а тези на
midt-bg#306 получиха 0006/0007.

Пълният суит е зелен: db 438, web 482, ingest 84, etl 20, shared 45, config 10.
Typecheck и prettier чисти.
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
…ара за 0010

Преномериране: midt-bg#307 взе 0006/0007, midt-bg#308 взе 0008, тъй че тези две се местят на
0009 и 0010. Обновени са всички препратки - тестове, deploy.yml,
related-persons-data.yml, scripts/cacbg/load.mjs, docs/deploy.md.

Коментарът над прилагането на 0010 в deploy.yml беше останал от предишния
замисъл и твърдеше три неверни неща: че 0003 обявявала CHECK-овете (тя е
върната непокътната и не обявява нищо), че се слагало control_hash NOT NULL
(остава NULL-ируема нарочно - регистърът я пропуска за част от декларациите,
а индексът по естествен ключ ги сгъва с COALESCE) и че миграцията е
„rebuild-based" (вече е тригерна; заглавието ѝ обяснява защо пресъздаването е
опасно - оголва външните ключове и обира доказателствените печати).

Последното беше най-неприятно: обещаваше на следващия четец точно операцията,
която самата миграция отхвърля, а „0003 declares them now" щеше да го прати
обратно да добавя CHECK-ове в приложена миграция.

docs/deploy.md вече изброява и стъпките, които сондират таблицата и добавят
само липсващите колони, за да не изглежда, че --file е единственият механизъм.

db 424 зелени, typecheck и prettier чисти, scripts/tr 134/134.
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
Преномерирането смени пътищата, но остави имената migration6Path/migration7Path,
което е подвеждащо и се сблъсква с едноименните променливи, които midt-bg#307 и midt-bg#308
въведоха за 0006/0007/0008.
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
Разрешени конфликти след midt-bg#307, midt-bg#310 и midt-bg#308.

Всички са от един и същ вид: и двете страни добавяха своя миграция към
веригата, с която тестът строи схемата, и понякога под едно и също име на
променлива. Сега всеки тест, който сервира amendments или пуска refresh-slice/
normalize-raw, прилага цялата верига - 0006/0007 (стойност на анекса),
0008 (провенанс) и 0009 (доказателствен печат). Иначе всеки набор пада на
липсваща таблица или колона от другия.

Пълният суит е зелен: db 474, web 493, ingest 84, etl 20, shared 45, config 10.
Typecheck и prettier чисти.
todorkolev added a commit that referenced this pull request Aug 14, 2026
Бележка 1 (тест минаваше вакуумно): всяко търсене вече твърди, че редът
СЪЩЕСТВУВА, преди да твърди нещо за него - иначе `row?.value_flag` върху
undefined кара `.not.toBe(...)` да мине без нищо да е проверено. Плюс
асерция за `amount_eur` на пощадения ред: важното при него е, че си запазва
парите.

Бележка 2 (документацията): docs/etl.md описваше `value_suspect` само като
`eff > 200 * procEst`. Сега изброява и трите повода, включително стотинки
лентата в двете ѝ форми - спрямо процедурната прогноза (#298) и спрямо
прогнозата по позиция (#247) - и обяснява защо второто рамо иска и
`eff >= 10 * procEst`. Добавен е и `annex_total_suspect` от #305, който липсваше
в подредения списък, а редът в него има значение.

Бележка 3 (покритие): нови случаи за двата ръба на лентата (95x и 105x влизат,
94x и 106x не), за двата пода от 1000 EUR поотделно, и за ред без собствена
прогноза. Всеки от тях убива мутация: 95→90, 105→110, махане на който и да е
от двата пода вали по два теста.

Бележки 4 и 5 (реконсилиационният пас): собствената прогноза там се вадеше от
`classifier_estimated_value` (който пада към процедурната) и се превръщаше по
валутата на ДОГОВОРА. Сега е като на четирите INSERT места - собствената
прогноза на реда, NULL когато няма такава, превърната по СВОЯТА си валута
(`procurement_currency → currency → BGN`) с датирано fx търсене.

Валутната част има видима цена: прогноза в евро при договор в лева се делеше
на 1,95583 и падаше извън лентата, тъй че същият договор получаваше различен
флаг според това кой път го е пипнал последен - и се показваше с 51 129 EUR
вместо с 500 000. Има тест, който го заковава.

Липсата на fallback-а е изчистване на смисъла, не промяна в поведението, и
това е проверено: мутация, която го връща, не вали нито един тест. Причината
е, че при fallback собственото рамо пали точно там, където процедурната лента
и без това пали. Записвам го както е, а не както би било по-ласкателно.

Премерено наново върху корпуса след #307: същите 7 договора, 40,3 млн. EUR
показвани, 1,75 млн. след поправката. Осмият, който лентата хваща, вече е
`value_suspect` по правилото за 200x - тоест рамото се съгласява с
предшественика си там, където се застъпват. Договори, палещи едновременно
това рамо и новия `annex_total_suspect`: нула - но и целият корпус още няма
нито един `annex_total_suspect`, защото флагът се пълни при стейджване на
анекса, а кронът пуска само прозореца. Тоест въпросът за предимството остава
непремерен до пълен derive; по конструкция `value_suspect` печели, защото е
първи в CASE-а.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants